Skip to content

Add pure RSS fetch acceptance contract#39

Open
Pigbibi wants to merge 2 commits into
mainfrom
feat/pert-weekly-fetch-acceptance-w0a
Open

Add pure RSS fetch acceptance contract#39
Pigbibi wants to merge 2 commits into
mainfrom
feat/pert-weekly-fetch-acceptance-w0a

Conversation

@Pigbibi

@Pigbibi Pigbibi commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Pure fetch/content/status acceptance foundation for the existing PERT RSS producer. This PR does not modify workflow, publication, upload, QAR, Pages, or weekly artifact code.

Contract

  • configured feed results must be non-empty
  • only RSS 2.0 and Atom feed roots are accepted; well-formed unrelated XML fails closed
  • entry accepted/rejected counts are explicit and exact
  • malformed entries, failed/stale/missing feeds, and counter mismatches cannot produce publication_complete=true
  • recognized feeds with zero entries use explicit quarantine: debug evidence may remain, but live eligibility is false
  • status has a strict exact schema, canonical sorted JSON serializer/parser, duplicate/noncanonical/unknown/missing/tampered input rejection, and sanitized stable error codes

Validation

  • python3 -m pytest -q — 122 passed
  • uv sync --locked --extra test && uv run pytest -q — 122 passed
  • python3 -m compileall -q src scripts tests — passed
  • git diff --check — passed
  • ruff unavailable in environment

Non-scope

No workflow wiring, publication/upload changes, QAR/Pages/publisher, weekly artifact, new dependencies, secrets, or permissions.

Co-Authored-By: Codex <noreply@openai.com>
@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

🤖 Codex PR Review

🚫 Merge blocked: 2 serious issue(s) found in high-risk files

⚖️ Codex Review Arbitration

🚫 block: Both current findings are still supported by the cumulative PR diff. First, src/political_event_tracking_research/fetch_acceptance.py still gates XML safety with _FORBIDDEN_DECLARATION.search(payload) on raw bytes and then calls ET.fromstring(payload), so the code has no parser-level guarantee that DTD/entity handling is disabled; the added post-parse node/depth/text limits occur only after fromstring returns, so they do not refute the reported pre-parse XML bomb bypass risk for alternate encodings. Second, the regex rb"<!\s*(?:doctype|entity|element|attlist|notation)\b|\b(?:system|public)\b" unconditionally matches any standalone system or public token anywhere in the payload, which means ordinary feed text/URLs containing those words will be rejected as xml_forbidden_declaration; neither the implementation nor the tests narrows that behavior to actual declaration syntax. The current findings do not conflict with prior blocking history: the security finding preserves the prior requirement for hardened XML parsing, and the new logic finding is unrelated to the prior row-count validation issue.

🚫 Blocking Issues

These issues must be fixed before this PR can be merged:

1. 🔴 [CRITICAL] Security in src/political_event_tracking_research/fetch_acceptance.py

DTD/ENTITY 过滤是在原始字节上用 ASCII 正则做的。只要 feed 使用 encoding="UTF-16" 等多字节编码,<!DOCTYPE>/<!ENTITY> 就可以绕过这段扫描,而 ElementTree.fromstring() 仍会解析这些声明;这样会重新打开实体扩张/XML bomb 的拒绝服务入口,且后面的节点/深度上限是在解析完成后才检查,已经来不及了。 (line 94)

Suggestion: 不要依赖原始字节正则过滤 XML 声明。应先严格限制/规范化允许的编码,再使用明确禁用 DTD/ENTITY 的解析器配置(例如 defusedxml 或等效的安全 XML 解析方案),并在进入标准解析前拒绝所有带声明的危险文档。

2. 🟠 [HIGH] Logic in src/political_event_tracking_research/fetch_acceptance.py

_FORBIDDEN_DECLARATION 的第二个分支会匹配 payload 任意位置出现的独立单词 systempublic,而不要求它们出现在 <!DOCTYPE ... SYSTEM/PUBLIC ...> 声明里。结果是只要正常文章标题、正文、URL 或摘要里含有这些词,整个合法 feed 都会被错误标记成 xml_forbidden_declaration 并硬失败。 (line 39)

Suggestion: 把检测范围收窄到真实的声明语法,而不是全局匹配 system|public;更稳妥的做法是移除这类关键字正则,改用安全 XML 解析器直接禁用 DTD/ENTITY。

ℹ️ Other Findings

1. 🟡 [MEDIUM] Logic in src/political_event_tracking_research/fetch_acceptance.py

_validate_feed_result()state == "quarantined" 只要求 error_code 非空,没有约束计数和错误类型的一致性。这样像 error_code="zero_entries"accepted_row_count=100rejected_row_count=0 这类不可能状态会被 parse_status_bytes() 当成合法 canonical 状态接受,破坏了 PR 声称的“显式且精确计数 / 篡改输入拒绝”契约。 (line 167)

Suggestion: 为 quarantined 状态补充按错误码的严格不变量校验,例如 zero_entries 必须是 accepted=0,rejected=0entry_invalid 必须满足 rejected_row_count > 0 且总条数大于 0;不要只校验 error_code 是否存在。

2. 🟡 [MEDIUM] Bug in src/political_event_tracking_research/fetch_acceptance.py

build_acceptance_status() 的公开签名声明接收 Iterable[Mapping[str, object]],但实现里直接要求 feed_results 必须是 list。任何调用方如果传入 tuple、迭代器或生成器,都会被误判为 configured_feed_empty,这是一个真实的 API/接线层故障点。 (line 173)

Suggestion: 先把输入物化为列表再校验空值,例如 feed_results = list(feed_results);如果本函数只支持列表,就把类型签名和所有调用约束都收窄为 list[...],避免公开契约与实现不一致。


Review by Codex PR Review bot • PR

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 76fa6470bf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +123 to +124
if result["state"] == "accepted" and (result["accepted_row_count"] <= 0 or result["rejected_row_count"] != 0 or result["error_code"] is not None):
raise _fail("feed_result_invalid")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require accepted feeds to be RSS or Atom

When a caller constructs a feed result instead of using classify_feed_payload (for example while recording external feed states), this validation allows kind="unknown" with state="accepted" and a positive accepted count. build_acceptance_status then treats that feed as successful and sets publication_complete/eligible_for_live_publication to true, so a canonical status can certify non-RSS/Atom content as publishable; accepted feed results should be constrained to rss or atom here.

Useful? React with 👍 / 👎.

parsed = datetime.fromisoformat(normalized)
except (TypeError, ValueError):
return False
return parsed.tzinfo is not None and parsed.astimezone(timezone.utc) is not None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat overflowed timestamps as invalid rows

For feeds containing a syntactically valid timestamp that overflows when converted to UTC (for example 9999-12-31T23:59:59-23:59), fromisoformat/parsedate_to_datetime succeeds but this astimezone call raises OverflowError. That exception escapes classify_feed_payload, so a single malformed entry aborts classification instead of being counted as rejected and quarantining the feed.

Useful? React with 👍 / 👎.



def build_acceptance_status(feed_results: Iterable[Mapping[str, object]]) -> dict[str, object]:
if not isinstance(feed_results, list) or not feed_results:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept all iterables promised by the API

The public signature accepts Iterable[Mapping[str, object]], but this guard rejects any non-list iterable before reading it. A natural integration such as passing a tuple or generator of classify_feed_payload(...) results will raise configured_feed_empty even when results are present; convert the iterable to a list before checking emptiness, or narrow the signature to list.

Useful? React with 👍 / 👎.

Comment on lines +87 to +89
root = ET.fromstring(payload)
except (ET.ParseError, UnicodeError, ValueError):
return _feed_result(feed_id, feed_url, "unknown", 0, 0, "failed", "xml_invalid")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail closed on unknown XML encodings

For malformed XML that declares an unknown encoding, ET.fromstring raises LookupError rather than ET.ParseError/UnicodeError/ValueError. Because this exception is not caught, classify_feed_payload aborts instead of returning a failed xml_invalid feed result, so one bad payload can bypass the intended fail-closed acceptance status path.

Useful? React with 👍 / 👎.

Co-Authored-By: Codex <noreply@openai.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: df843262ae

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

MAX_XML_NODES = 10000
MAX_XML_TEXT_BYTES = 256 * 1024
MAX_XML_ATTRIBUTES = 128
_FORBIDDEN_DECLARATION = re.compile(rb"<!\s*(?:doctype|entity|element|attlist|notation)\b|\b(?:system|public)\b", re.IGNORECASE)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict forbidden XML keywords to declarations

This pattern matches standalone public or system anywhere in the raw payload, not just inside <!DOCTYPE ... PUBLIC/SYSTEM ...> declarations. A valid political RSS item such as <title>Public hearing</title> is therefore classified as xml_forbidden_declaration, causing the feed to fail and preventing live publication even though the XML is well-formed RSS/Atom; scope those alternatives to declaration markup instead of scanning all content.

Useful? React with 👍 / 👎.

raise _fail("feed_payload_invalid")
if len(payload) > MAX_XML_BYTES:
raise _fail("xml_oversize")
if _FORBIDDEN_DECLARATION.search(payload):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize encoded XML before scanning declarations

When a feed declares UTF-16/UTF-16LE, this raw byte scan never sees contiguous <!DOCTYPE or <!ENTITY tokens, but ET.fromstring below honors the encoding and expands the internal entity; a UTF-16 RSS payload with a forbidden DTD is currently returned as an accepted feed instead of xml_forbidden_declaration. Since the contract is fail-closed for forbidden declarations, normalize or restrict encodings before this check.

Useful? React with 👍 / 👎.

Comment on lines +64 to +65
if child.tag == f"{{{_ATOM}}}link" and child.attrib.get("href"):
return child.attrib["href"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip Atom hrefs before accepting rows

For Atom entries whose only link is href=" ", this condition treats the whitespace as a usable link, so row_valid counts the entry as accepted and build_acceptance_status can mark the feed publishable even though the downstream source_url would be blank/whitespace. Strip the attribute and require a non-empty value before returning it, matching how RSS text fields are handled.

Useful? React with 👍 / 👎.

Comment on lines +165 to +166
if state == "quarantined" and not error_code:
raise _fail("feed_result_invalid")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate quarantined counters against the reason

When external feed states are passed directly, this check only requires any error code for quarantined feeds, so a result like state="quarantined", error_code="zero_entries", and a positive accepted_row_count is accepted and serialized as canonical. That makes the strict status report accepted rows for a feed that claims to have zero entries, undermining the exact counter contract; reject quarantine reasons whose row counts are inconsistent with the reason.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant